[OpenCV] 画像処理で気圧計の値を読み取ってみました
1 はじめに
製造ビジネステクノロジー部の平内(SIN)です。
本ブログは クラスメソッド発 製造業 Advent Calendar 2024 23日目の記事です。
工場などには様々な計器があり、それらを監視するニーズは比較的多く存在すると思います。
計器のメーター値をデジタル化したい場合、最も簡単で確実な方法は、数値を出力できるデジタルメーターに置き換えることでしょう。しかし、様々な制約により、アナログメーターを目視で読み取る作業がどうしても残ってしまうケースが考えられます。
今回は、そのような場面をイメージし、カメラで撮影した画像からメーターの値を読み取る作業を試してみました。
2 処理後の出力の説明
処理後の画像に表示されているものは、以下のとおりです。
- center: 圧力計の中心座標
- pointer: 針先の座標
- deg: 針の角度
- value: 読み取られた圧力計の値
- 緑色の円: 圧力計の外縁
- 水色の線: 指針
- 白色の矩形: 圧力計の中心エリア
- 赤色の線及び数値: 圧力計の始点と終点の角度
角度については、真下を0度として、時計回りに増加するものとして処理しています。
3 処理手順
圧力計の画像を処理して、その値を読み取る手順は、以下のとおりです。
(1) 画像の正規化(後段で、サイズによる抽出が可能になるよう、一定のサイズに変換しています)
(2) 外縁の検出(圧力計の外縁「円形」を取得します)
(3) 直線の検出(画像に写っている直線を検出します)
(4) 指針の検出(検出した直線の中から、「圧力計の針」を検出します)
(5) 針先の判定(「圧力計の針」の始点・終点のうち、針先がどちらであるかを判定しています)
(6) 角度の算出(針先の座標と中心座標の関係から、圧力計の針の角度を算出しています)
(7) 値の取得(針先の角度を、圧力計の値に変換しています)
(1) 画像の正規化
読み込んだ画像は、後段の外縁や指針の検出時に、サイズによる判定が可能となるように、横幅 640pxで正規化してます。
org_h, org_w, _ = image.shape
fx = 640 / org_w
image = cv2.resize(image, None, fx=fx, fy=fx)
new_h, new_w, _ = image.shape
print(f"read_image {org_w} x {org_h} => fx: {fx} => {new_w} x {new_h}")
(2) 外縁の検出
圧力計の外縁を検出します。
OpenCVの findContours() で、画像の輪郭を検出できます。
# 2値化画像の作成
def create_binarize_image(image):
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
_, binary = cv2.threshold(gray, 120, 220, cv2.THRESH_BINARY)
return binary
# 気圧計の外円を検出
def detect_circle(binary):
contours, _ = cv2.findContours(binary, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
for contour in contours:
(x, y), radius = cv2.minEnclosingCircle(contour)
center = (int(x), int(y))
radius = int(radius)
if 110 < radius < 130:
print(f"detect_circle center:{center} radius:{radius}")
return center, radius
return (0, 0), 0
binary_image = create_binarize_image(image)
center, radius = detect_circle(binary_image)
輪郭検出は、黒い背景から白い物体を検出する動作となっているため、入力画像は、2値化処理した画像を使用しています。
検出した輪郭から、最小外接円を計算する時は cv2.minEnclosingCircle()を使用します。そして、その結果が下記のとおりです。多数の円が検出されていることが分かります。
多数の円から、圧力計の外縁を抽出するには、妥当なサイズかどうかで判定してます。画像は、横幅が640pxに正規化されているため、そこに写る圧力計の半径は、概ね120px程度になります。
そこで、検出結果から、半径が、100px以上、130px未満のものだけを抽出することで、外縁としています。
円は、「中心点」及び「半径」で表現されているため、この作業で、同時に圧力計の 「中心」 も取得できたことになります。
(3) 直線の検出
圧力計の針を検出するための前作業として、画像に写っている直線を検出しています。
OpenCVの HoughLinesP() で、ハフ変換による直線検出が可能です。
# エッジ画像を生成
def create_edges_image(binary_image):
return cv2.Canny(binary_image, 50, 150, apertureSize=3)
# 直線検出
def detect_lines(edges_image):
return cv2.HoughLinesP(
edges_image, 1, np.pi / 180, threshold=70, minLineLength=50, maxLineGap=10
)
edges_image = create_edges_image(binary_image)
lines = detect_lines(edges_image)
直線を検出しやすいように、入力画像は、エッジ画層を使用しています。
下記は、HoughLinesP() で検出した直線を赤色で描画したものです。目的である 圧力計の針 以外の直線も検出されることがありますが、この時点では、いったん、このまま後段に送ります。
(4) 指針の検出
上記で検出した直線の中から指針を検出するには、圧力計の中心(一定サイズの矩形)と交差しているかどうかで判定しています。
# 中央部を表現する矩形
def get_center_area(center, margin):
return (center[0] - margin, center[1] - margin, margin * 2, margin * 2)
# メータの針を検出
def detect_pointer(center_area, lines):
for line in lines:
x1, y1, x2, y2 = line[0]
if crossing_detection((x1, y1, x2, y2), center_area):
print(f"detect_pointer ({x1},{y1}) ({x2},{y2})")
return (x1, y1), (x2, y2)
return None
center_area = get_center_area(center, center_margin)
pointer = detect_pointer(center_area, lines)
圧力計の中心から一定のサイズの矩形(白色)を作成します。
続いて、検出された複数の直線が、矩形の、上辺、下辺、左辺、右辺の4つの直線と、交差しているかどうかを確認し、中央部を通過している直線を検出し、指針としています。
なお、交差検出( crossing_detection ) のコードは、以下のとおりです。
# 直線の交差検出
def crossing_detection_line(p1, p2, q1, q2):
def is_clockwise(a, b, c):
return (c[1] - a[1]) * (b[0] - a[0]) > (b[1] - a[1]) * (c[0] - a[0])
return is_clockwise(p1, q1, q2) != is_clockwise(p2, q1, q2) and is_clockwise(
p1, p2, q1
) != is_clockwise(p1, p2, q2)
# 直線と矩形の交差検出
def crossing_detection(line, rect):
x1, y1, x2, y2 = line
rx, ry, rw, rh = rect
rect_lines = [
((rx, ry), (rx + rw, ry)),
((rx, ry), (rx, ry + rh)),
((rx + rw, ry), (rx + rw, ry + rh)),
((rx, ry + rh), (rx + rw, ry + rh)),
]
for rect_line in rect_lines:
if crossing_detection_line((x1, y1), (x2, y2), rect_line[0], rect_line[1]):
return True
return False
(5) 針先の判定
指針として検出した直線の始点と終点のうち、針先を表現しているのは、どちらであるかは、中心との距離で判定しています。
numpyの np.linalg.norm でベクトルの大きさを算出できるため、この処理は下記のように簡易に記述できます。
# 針先の座標を取得
def get_pointer_coordinates(pointer, center):
c = np.array(center)
a = np.array(pointer[0])
b = np.array(pointer[1])
return pointer[0] if np.linalg.norm(c - a) > np.linalg.norm(c - b) else pointer[1]
point = get_pointer_coordinates(pointer, center)
(6) 角度の算出
針先の座標と、中心座標を使用して、指針の角度を算出しています。
指針の座標は、中心点の座標で0,0からの差分座標として変換し、np.arctan2() でatan「ラジアン」を出力し、math.degrees() で「度」に変換しています。
※ arctan2() の出力は、真上を0とした、-pi < θ <= piとなるため、最後に、真下を基準にするため180°加算しています。
# 針先の角度を取得
def get_deg(point, center):
x = point[0] - center[0]
y = (point[1] - center[1]) * -1
rad = np.arctan2(x, y)
deg = math.degrees(rad) + 180
return round(deg, 2)
(7) 値の取得
指針の値は、その角度と、メモリの始点と終点の角度からメータの値を計算できます。
# 針先の角度をメータ値に変換する
def convert_to_meter_value(deg, start_deg, end_deg):
value = (deg - start_deg) / (end_deg - start_deg)
return round(value, 2)
4 別の種類の圧力計
こちらは、別の種類の圧力計を読み取ってみたものです。上記で試したもの圧力計を比べると、始点・終点の角度や、目盛り(こちらは、0〜1 ではなく、0〜12でした)に違いがあるのですが、パラメータの一部を修正するだけで、利用可能であることが確認できました。
5 補足(メータ針の側面で、2つの直線が検出される場合)
針の形によっては、左の図のように、直線検出時に針の側面で2本の直線(緑色の表示)が検出されることがあります。
この事が、誤差発生に影響するように思いますが、ロジック上、メータ値を読み取るための角度計算は、直線の先端座標と、円の中心座標で計算(青色の表示)されるため、
どちらの直線を使用しても、結果は同じになります。
6 最後に
今回の作業は、圧力計が、垂直に写っていることを前提としています。もし、傾いた場合も考慮するのであれば、何らかの基準を設けて、その傾きを計算する必要があります。
また、外縁を基準としているので、カメラの角度等で、外縁の検出にヅレが生じると、結果的に全体の判定に影響が出ることを確認しました。
見て分かる通り、数値の判定には、やや誤差が生じてしまっていることをご了承ください。
すべてのコードは、Githubに置きました。
余談:買ってきた圧力計は、いろいろな数値を模擬するためにバラしました。すいません、もう、使えません。